一般情况下,当请求一个包含 Service Worker 的页面,并且此 Service Worker 尚未运行,那么浏览器将会等到 Service Worker 启动之后才会发起导航请求(如下图),也由于受各种因素的影响,Service Worker 的启动时间会有不同程度的延迟,这种延迟将直接导致导航请求的延迟,进而增加了页面的整体渲染时间。

上文中我们提到了导航请求,这里我们先简单了解下相关概念,在 Fetch 规范中的定义为:请求实体为 document 的请求。通俗来讲就是当我们在浏览器的地址栏中输入网址,或通过链接等手段从一个页面跳转到另外一个页面时所发送的请求。由于导航请求响应中的 HTML 负责启动所有脚本、样式、图片等资源的请求,因此任何导航请求的延迟都终将导致空白页问题的出现。
正是为了解决因 Service Worker 启动而导致导航请求的延迟问题,Service Worker 提供了导航预加载机制,该机制在 Service Worker 开始启动时,便立刻发起导航请求,这样 Service Worker 启动便能与导航请求并行执行(如下图),从而大大降低了因延迟而导致空白页的几率。

# 使用
导航预加载的使用非常简单,首先在
Service Worker的activate事件中启用该功能:
self.addEventListener('activate', event => {
event.waitUntil((async () => {
if (self.registration.navigationPreload) {
await self.registration.navigationPreload.enable();
}
})());
});
然后在
Service Worker的fetch事件中将预加载的导航请求响应返回即可:
self.addEventListener('fetch', event => {
const { request } = event;
if (request.method.toLowerCase() === 'get') {
event.respondWith((async () => {
//...其他类型请求处理逻辑
const preloadResponse = await event.preloadResponse;
if (preloadResponse) {
return preloadResponse;
}
})());
}
});
- 需要注意的是:如果开启了导航预加载,那么在
fetch事件中必须对event.preloadResponse进行消费,否则这将导致该请求会被请求两次。 - 导航预加载请求中会携带请求头
Service-Worker-Navigation-Preload,且默认值为true,可通过以下方式来修改其默认值:
navigator.serviceWorker.ready.then(registration => {
return registration.navigationPreload.setHeaderValue(newValue);
});
# 与应用 Shell 集成
在应用 Shell 中,我们通过将 top shell、正文信息、bottom shell 等内容拼装在一起的方式来响应页面请求,该方式虽然很大程度上解决了恶劣网络环境下的页面响应问题,但根据上文的论述可以得知,正文信息的请求响应依旧存在着一定程度的延迟,因此本节我们将尝试将两种技术融合在一起使用,以求得到更快速的响应。
function fetchPage(cacheKey, event) {
//... 根据 cacheKey 获取 shell 类型
const stream = new ReadableStream({
start(controller) {
//... pushStream 函数定义
(async () => {
//... top shell 处理逻辑
let context;
try {
context = await event.preloadResponse;
} catch {
}
if (!context) {
context = await fetch(cacheKey, {
headers: {
'only_content': 1
}
});
}
if (content) {
await pushStream(content.body);
} else {
const errorContent = new Response(
'<div class="message">网络错误</div>',
{ headers: { 'Content-Type': 'text/html' } }
);
await pushStream(errorContent.body);
}
//... bottom shell 处理逻辑
controller.close();
})();
}
});
return new Response(stream, {
headers: { 'Content-Type': 'text/html' }
});
}
self.addEventListener('fetch', event => {
const { request } = event;
if (request.method.toLowerCase() === 'get') {
event.respondWith((async () => {
const cacheKey = new URL(request.url, location).pathname;
//...其他类型请求处理逻辑
return fetchPage(cacheKey, event);
})());
}
});
上述代码基于应用 Shell 中所展示的代码为基础进行了修改:
fetchPage方法增加了fetch事件的event参数,以便获取导航预加载请求响应(即:event.preloadResponse)。- 在
fetchPage方法中,我们首先尝试获取导航预加载请求响应:
let context;
try {
context = await event.preloadResponse;
} catch {
}
如果导航预加载请求响应出现异常(比如服务器不响应)或响应内容为空,则尝试通过传统的 fetch 方法获取正文信息:
if (!context) {
context = await fetch(cacheKey, {
headers: {
'only_content': 1
}
});
}
由于 Service Worker 获得页面的控制权后,所有的页面请求都只需要返回正文部分的信息即可,而导航预加载请求并未携带头信息 'only_content': 1,故我们需要修改服务端代码以适应其变化:
async function renderPage(ctx, type, content) {
const { headers } = ctx.request;
if (parseInt(headers['only_content'], 10) === 1 || headers['service-worker-navigation-preload'] === 'true') {
//.... 返回正文部分
} else {
//... 返回整个文档
}
}
# 总结
上文中,我们首先对为解决因 Service Worker 启动而导致导航请求延迟问题的导航预加载进行了说明,然后,介绍了如何与应用 Shell 搭配使用来进一步加速页面的渲染。至此,我们完成了预缓存、应用 Shell 及导航预加载的学习,相信大家此刻已经能够很好的处理恶劣网络环境下的页面响应问题。然而在实施这些方案时,往往会发现我们与之打交道最多的便是缓存,那么究竟如何处理缓存的使用与更新这些问题呢?在接下来的章节中将为大家一一讲解。